feat(reservations): persist CRUD via raft WAL and enforce scheduling parity#385
feat(reservations): persist CRUD via raft WAL and enforce scheduling parity#385sgopinath1 wants to merge 5 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #385 +/- ##
==========================================
+ Coverage 68.22% 68.67% +0.44%
==========================================
Files 134 134
Lines 36087 37173 +1086
==========================================
+ Hits 24619 25525 +906
- Misses 11468 11648 +180 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds durable, Raft-persisted node reservations to Spur and enforces reservation semantics across scheduling, backfill, display surfaces, and preemption behavior.
Changes:
- Persist reservation CRUD via Raft WAL operations and apply-path replay.
- Enforce reservation eligibility in scheduling/backfill (including future overlap fencing) and introduce reservation-driven pending reasons / formatting.
- Integrate reservations into UX/API surfaces (proto fields, CLI display/format codes) and refine preemption logic around active reservations.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/native_host/e2e/test_reservations.py | Adds native-host E2E coverage for reservation create/list/delete and scheduling enforcement. |
| proto/slurm.proto | Extends public proto with reservation fields (job reservation, node maint flag, reservation flags/state). |
| crates/spurdbd/src/server.rs | Populates new JobInfo.reservation field for accounting responses. |
| crates/spurctld/src/server.rs | Validates/accepts reservation flags at API boundary; lists reservation flags/state; annotates nodes with reservation info. |
| crates/spurctld/src/scheduler_loop.rs | Runs reservation lifecycle hooks each scheduler tick; expands preemption logic to account for reservations and partition policy. |
| crates/spurctld/src/cluster.rs | Moves reservation CRUD onto propose→apply Raft path; adds validation, purge/expiry handling, and reservation-driven pending ordering/reasons. |
| crates/spur-tests/src/t55_format.rs | Adds formatting parity mappings for new pending reasons. |
| crates/spur-tests/src/t50_core.rs | Updates core tests for new reservation flags field. |
| crates/spur-tests/src/t07_sched.rs | Updates scheduler tests for new reservation flags field. |
| crates/spur-sched/src/backfill.rs | Adds prospective reservation overlap checks and reservation window fit constraints to backfill scheduling. |
| crates/spur-core/src/wal.rs | Introduces WAL variants for reservation create/update/delete plus serialization round-trip tests. |
| crates/spur-core/src/reservation.rs | Adds reservation flags, overlap helpers, node-list normalization, and prospective overlap logic. |
| crates/spur-core/src/job.rs | Introduces new PendingReason values for reservation maintenance/deletion. |
| crates/spur-core/src/config.rs | Adds resv_overrun_minutes scheduler config for reservation end grace period. |
| crates/spur-cli/src/squeue.rs | Adds %v job format spec for reservation name display. |
| crates/spur-cli/src/smd.rs | Displays maint vs resv for nodes with active maintenance reservations. |
| crates/spur-cli/src/sinfo.rs | Displays maint vs resv for idle nodes with active maintenance reservations. |
| crates/spur-cli/src/scontrol.rs | Adds --flags to reservation creation and shows reservation/node reservation-related fields. |
| crates/spur-cli/src/format_engine.rs | Adds header label for %v reservation column. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if reservations.iter().any(|r| r.name == res.name) { | ||
| /// Create a new reservation (validated, persisted via Raft). | ||
| pub fn create_reservation(&self, mut res: Reservation) -> anyhow::Result<()> { | ||
| if self.reservations.read().iter().any(|r| r.name == res.name) { |
There was a problem hiding this comment.
Blocking: TOCTOU race on reservation name uniqueness. This check reads self.reservations before propose(); two concurrent create_reservation calls for the same name can both pass it before either commits. apply_operation's ReservationCreate arm never re-checks for an existing name and unconditionally pushes, so duplicates can land. Worse, ReservationDelete's apply arm uses retain(|r| r.name != *name), which would then remove both duplicates on a single delete. The uniqueness invariant needs to be enforced inside apply_operation — the single serialized point all replicas agree on — not just at propose time. Once fixed, a regression test spawning two same-name creates and asserting only one survives would pin down the invariant.
There was a problem hiding this comment.
Fixed. apply_operation now dedupes ReservationCreate at the serialized apply point; ClientResponse.reservation_created reports whether it applied, and the RPC returns AlreadyExists otherwise. Added concurrent_reservation_create_keeps_single_entry test.
| name: name.to_string(), | ||
| })?; | ||
|
|
||
| if !res.flags.no_hold_jobs { |
There was a problem hiding this comment.
Blocking: no_hold_jobs doesn't actually let jobs be rescheduled. Skipping hold_jobs_for_deleted_reservation here (and in purge_expired_reservations) never clears job.spec.reservation — no code path does. reservation_block() unconditionally returns Some(PendingReason::Reservation) when the named reservation no longer exists, and pending_jobs() filters those out entirely. So jobs from a no_hold_jobs-deleted reservation are still permanently excluded from scheduling — functionally identical to being held, just without the Held label. A minimal regression test (extend purge_expired_holds_pending_reservation_jobs below at line ~5363): create a reservation with no_hold_jobs, submit a job against it, delete/expire it, assert the job is still eligible via pending_jobs().
There was a problem hiding this comment.
Fixed. Delete/purge now clears job.spec.reservation via detach_jobs_from_deleted_reservation_jobs in the WAL apply path, so reservation_block() no longer filters those jobs. Added no_hold_jobs_reservation_delete_does_not_permanently_block_job and purge variant tests.
| } | ||
| } | ||
|
|
||
| fn reservation_fence_reason( |
There was a problem hiding this comment.
Suggestion: this fences on any node being reservation-blocked, even if 99 other free nodes would satisfy the job, because it continues before the more accurate Resources/NodeDown/Priority classification runs. It's diagnostics-only (actual placement uses prospective_overlap independently in spur-sched), but in any cluster with an active/upcoming reservation covering even one node, unrelated pending jobs can be mislabeled ReqNodeNotAvail/ReservedMaintenance in squeue/scontrol when the real reason is unrelated.
There was a problem hiding this comment.
Fixed. Fence reasons are only set when all candidate nodes are reservation-blocked (job_candidate_node_names). Diagnostics-only path; scheduling still uses prospective_overlap independently.
| let Some(job) = jobs.get(&job_id) else { | ||
| return Ok(()); | ||
| }; | ||
| if job.requeue_count >= MAX_REQUEUE { |
There was a problem hiding this comment.
Suggestion (pre-existing gap, not introduced by this PR): once requeue_count >= MAX_REQUEUE, the job returns Ok(()) and stays in JobState::Preempted forever — neither is_terminal() nor is_active() — so it never completes, gets cancelled, or gets picked up by any further scheduler hook. Consider transitioning to Cancelled with a distinguishing exit reason once retries are exhausted.
There was a problem hiding this comment.
Fixed. Exhausted requeues now transition to held Pending with JobHoldMaxRequeue (configurable via max_batch_requeue in spur.conf) instead of staying in Preempted forever. Admin can release with scontrol release.
| } | ||
|
|
||
| #[allow(clippy::result_large_err)] | ||
| fn reservation_rpc_status(err: impl std::fmt::Display) -> Status { |
There was a problem hiding this comment.
Nit: classifying gRPC status by substring-matching anyhow::Error::to_string() is fragile — any future rewording of an error message (including ones from hostlist::expand()/normalize_node_list(), which this function doesn't control) silently changes the client-facing status code. Consider a typed error enum or downcast_ref at the ClusterManager boundary instead.
There was a problem hiding this comment.
Fixed. Introduced typed ReservationError; reservation_rpc_status maps variants to gRPC codes directly — no to_string() substring matching.
| } | ||
| } | ||
|
|
||
| #[allow(clippy::result_large_err)] |
There was a problem hiding this comment.
Nit: reservation_rpc_status returns a bare Status, not a Result<_, E>, so #[allow(clippy::result_large_err)] can never fire here — looks like a copy-paste leftover, safe to remove.
There was a problem hiding this comment.
Fixed — removed the unused allow.
|
|
||
| /// Returns true when `[start, start+duration)` intersects a reservation on `node` | ||
| /// and the job is not authorized for that reservation. | ||
| fn start_overlaps_reservation( |
There was a problem hiding this comment.
Suggestion: this duplicates prospective_overlap in spur-core/src/reservation.rs — both implement "does [start, start+duration) intersect a reservation window this job isn't authorized for", just parameterized differently (now vs. an arbitrary future start). Consider generalizing prospective_overlap to take an explicit at: DateTime<Utc> and having this call it, so future changes to the authorization rule can't drift between the two call sites.
There was a problem hiding this comment.
Fixed. Added prospective_overlap_at(job, res, node, at, duration) in spur-core; backfill calls it so the authorization rule lives in one place.
| // Array jobs | ||
| uint32 array_job_id = 50; | ||
| uint32 array_task_id = 51; | ||
| string reservation = 52; |
There was a problem hiding this comment.
Suggestion: reservation = 52 is grouped under the // Array jobs comment above array_job_id/array_task_id, but it isn't array-related. Consider splitting into its own // Reservation comment line for readability — the field number itself (52) is fine as-is.
There was a problem hiding this comment.
Fixed — moved under its own // Reservation comment, field number unchanged.
| } | ||
|
|
||
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | ||
| async fn apply_reservation_create_update_delete() { |
There was a problem hiding this comment.
Test coverage: nearby tests (apply_reservation_create_update_delete, snapshot_and_restore) prove CRUD round-trips and snapshot/restore work, but none proves durability across an actual restart via WAL replay, which is this PR's headline claim. No reservation test drops a ClusterManager and starts a new one against the same on-disk raft log directory to assert the reservation reappears from replay (as opposed to a hand-invoked snapshot restore into a fresh dir2). Worth adding a reservation_survives_wal_replay test reusing this same TempDir/test_cluster scaffolding but restarting against dir.path().
There was a problem hiding this comment.
Added reservation_survives_wal_replay: drops ClusterManager, restarts against the same Raft dir, asserts reservation survives replay.
| } | ||
|
|
||
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | ||
| async fn purge_expired_holds_pending_reservation_jobs() { |
There was a problem hiding this comment.
Test coverage: only the default (holding) path is tested here. A minimal no_hold_jobs_reservation_delete_does_not_permanently_block_job test — create a reservation with no_hold_jobs set, submit a job against it, delete/expire the reservation, assert the job is still eligible via pending_jobs() — would catch the gap flagged above on delete_reservation/purge_expired_reservations.
There was a problem hiding this comment.
Added no_hold_jobs_reservation_delete_does_not_permanently_block_job and purge variant — create with no_hold_jobs, submit job, delete/expire, assert job is still in pending_jobs().
…parity Route reservation create/update/delete through WalOperation so changes survive log replay, not just periodic snapshots. Add MAINT, IGNORE_JOBS, and NO_HOLD_JOBS flags with overlap validation, prospective backfill blocking, lifecycle enforcement (purge, end-time cancel, hold on delete), and scontrol/sinfo display updates including maint-fenced pending reasons.
Add OVERLAP flag with reservation overlap validation, resv_overrun_minutes grace for end-of-window job cancel, reservation-first pending job ordering, PreemptMode-aware preemption with reservation immunity unless priority_tier is higher, JobInfo.reservation proto field, and squeue %v column.
Reject unknown reservation flags, fix backfill window for future starts, gate preemption on node overlap, hold pending jobs on purge_expired, add WAL apply and e2e coverage, and fix spurdbd JobInfo.reservation.
Block unauthorized jobs on active reservations in prospective_overlap, treat unknown running-job end times as occupied, split reservation RPC errors (internal vs not_found vs invalid_argument), OR maint flags across overlapping reservations, and wait on specific job states in e2e tests.
Make max-requeue holds and reservation delete/purge job updates WAL-atomic via extended JobStateChange and JobPriorityChange. Add max_batch_requeue config, JobHoldMaxRequeue hold reason, overlap and no_hold_jobs flags, accounting reservation tracking, and e2e coverage.
Thanks for reviewing @yansun1996. Please check the fixes and responses. |
yansun1996
left a comment
There was a problem hiding this comment.
All 10 of my original findings are genuinely fixed in the latest push (d3cd08c) — verified by reading the actual code, not just the reply text:
- TOCTOU race on reservation names — fixed via a proper re-check in
apply_operation's serializedReservationCreatearm (reservation_createdflag) no_hold_jobspermanent block — fixed viadetach_jobs_from_deleted_reservation_jobsclearingjob.spec.reservationreservation_fence_reasonover-fencing — fixed, now requires all candidate nodes blocked- Preempted jobs stuck past max requeue — fixed via
hold_job_at_max_requeue - Brittle string-matched RPC status — fixed with a typed
ReservationErrorenum - Dead clippy attribute — removed
- Duplicated overlap logic — unified via
prospective_overlap_at - Proto field grouping —
reservation = 52now has its own comment header - WAL-replay durability — new test properly restarts against the same on-disk dir
no_hold_jobsregression coverage — dedicated tests added
Also confirmed Copilot's earlier 5 findings were genuinely addressed in the prior commit.
CI hasn't finished running against this commit yet — merge once it's green.
Summary
Adds durable node reservations with scheduling enforcement, and preemption interaction.
WalOperation(Raft log replay), not snapshot-only state.MAINT,IGNORE_JOBS,NO_HOLD_JOBS, andOVERLAPwith validation (unknown flags rejected at API boundary).pending_jobs(), future-start window fit vianow.max(res.start_time), and lifecycle hooks (purge_expired, end-time cancel withresv_overrun_minutesgrace, hold pending jobs on delete/purge unlessNO_HOLD_JOBS).PreemptMode; skips jobs in active reservations unless the preemptor has a higherpriority_tier; only considers running jobs on nodes the pending job could use.scontrol/sinfo/smdreservation state;PendingReason::ReservedMaintenance/ReservationDeleted; protoJobInfo.reservation,reservation_maint,ReservationInfo.state;squeue %vcolumn.Design notes
hold_jobs_for_deleted_reservationsetsPendingReason::ReservationDeletedafterhold_job(not WAL-persisted; same pattern as other hold reasons).IGNORE_JOBSbypasses running-job overlap checks at create time;OVERLAP/MAINTallow storage overlap between reservations.Test plan
cargo fmt --checkcargo clippy --workspace --exclude spur-ffi --all-targets --locked(RUSTFLAGS=-D warnings)cargo test --locked(unit tests acrossspur-core,spur-sched,spurctld,spur-cli,spurdbd)resv_overrungrace, preemption immunity, node-overlap gating,purge_expiredhold behavior, WAL apply for reservation CRUDtests/native_host/e2e/test_reservations.pyon bare-metal lab (create/list/delete, unauthorized block, authorized schedule, busy-node rejection)